iPod 5.5G · Minigolf_1_1_2563296.bin · measured 2026-08-20

Mini Golf Decomp Census

What it would take to turn the best-running iPod game into portable source, how many functions that is, and which route gets there without a compiler nobody has.

304game functions to decompile (20 942 ARM insns ≈ 84 KB)
65framework ordinals Mini Golf calls — the host library surface
0Thumb instructions, floats in game code, C++ vtables in game code
191of 304 functions already run just reaching name entry

Verdict

This is a moderate, well-bounded project — much smaller than a typical console decomp. The executable is 227 KB, of which only ~92 KB is code, and the code is C++ (armcc's C++ runtime is linked in — vtables, Pure virtual fn called) compiled by ARM's own armcc with 16.16 fixed-point arithmetic throughout. Ghidra decompiles every function cleanly (415 of 415, no failures) on a first, untyped pass. Everything the game asks of the outside world goes through 65 numbered import thunks whose semantics your reversing/asyncfileio-abi.md already documents and eapp-loader already implements in Rust.

Language: C++17 throughout. The original is C++, so hand decompilation recovers the game's classes directly; every target toolchain (Apple clang, MSVC, NDK, MinGW, devkitARM for 3DS) has full C++17. The emitter writes .cpp too, so there is no C/C++ boundary in the project. Runtime and generated layers use C++ as a better C (no exceptions, no RTTI, no hot-path allocation); the host library, platform layer, and hand-written code use idiomatic modern C++. The full code-quality rules — naming, commenting, structure, warnings-as-errors, sanitisers — are the first section of PLAN.md and bind every step of the work.

A byte-matching build is not realistic and not necessary: the compiler is an ARM ADS/RVCT release you cannot license, and you already own something better than a matching build for verification — a deterministic emulator with scripted input that can emit the exact sequence of framework calls a session makes. That is the oracle the port should be tested against.

What the binary is

RegionAddress rangeSizeWhat it is
eApp header + descriptors0x18000000–0x18000064100 Bmagic eapp, entry vector table at +0x14 (entry 0x180188c0, frame hook 0x1801891c), six framework descriptors with MD5 interface hashes
Import thunks0x18000064–0x18000a3c2.5 KB277 ldr pc,[pc,#imm] stubs: OpenGLES 179, Audio 61, AsyncFileIO 17, miscTBD 15, Settings 3, InputEvents 2
ARM C runtime0x18000a3c–0x18002c288 KB53 functions: heap (with the $$$$ a^n + b^n = c^n | n>2 $$$$ sentinel), memcpy 0x18001124, memset 0x18001190, __rt_udiv 0x18001468, 64-bit divide 0x18000ba4, signal/__rt_raise messages, semihosting svc 0x123456
Game code0x18002c28–0x18018e4088 KB304 functions, 20 942 instructions. 100% ARM state, no blx, no VFP, no softfloat calls
Read-only data0x18019000–0x1803000092 KBruntime messages, asset filenames (c%02dbank/%01d.wav, jdmgp.sav), and ~70 KB of bitmaps encoded as 16-level ASCII art ( #%'(*-.2357<@DG ramp) — sprites/glyphs baked as strings
Data / BSS start0x18030000–0x18037a1c31 KBtables, globals (Ghidra sees 912 distinct globals), input flags word at 0x18037a0c

Compiler identification

The runtime strings Pure virtual fn called, Redirect: can't open:, Out of heap memory, Heap memory corrupted, C++ library exception and the heap sentinel above are ARM Ltd's C library as shipped with ARM Developer Suite 1.2 / RealView (RVCT 2.x) — not GCC. Matching that output requires that exact armcc build (plus its exact options), which is not obtainable legitimately. decomp.me hosts a handful of armcc/RVCT versions; pinning the version from the library's byte patterns would be the first task if anyone ever wanted a matching build. I would not spend the effort: see the verification section.

Code character (from the Ghidra pass)

The function census

BucketFunctionsInstructionsWhat happens to it in a port
Import thunks5858Vanish — become direct calls into the host library
ARM C runtime531 983Replace with libc. Only 32 are called from game code and they reduce to ~10 semantics: memcpy, memset, strlen/strcpy family, udiv/sdiv/div64, malloc/free, printf-ish, and the init/exit shims
Game code30420 942Decompile. 70 are under 10 insns, 96 are 10–25, 71 are 25–50, 68 are 50–100, 35 are 100–200, 12 are 200–500, 5 are over 500
Total (Ghidra)41522 983

Dynamic coverage, measured with the --callgraph-dump flag I added to play this session: five scripted sessions covering boot, asset load, the title, name entry and its menus reached 191 of 304 game functions (8 413 insns). The unreached 113 include all twelve largest functions — the course renderer, ball physics, hole/score flow and the pause menu — i.e. roughly 60% of the instructions are gameplay code that only a played hole exercises.

The emulator cannot currently get past name entry, which is what blocked a gameplay coverage number. Confirming the name (the ✓ cell) issues AsyncFileIO #12 with a 0xc0debabe-tagged request and #14, both answered with zero today, and the game stays on the screen. That is the "why is no save ever written" open item in the ABI notes, now with a reproducible trigger: select, scroll to ✓, select. Separately, wheel acceleration makes scripted bursts land nondeterministically; the script format wants a "move N cells" primitive.

The five largest, for scale

EntryInsnsCalleesReading
0x1800a0802 09233course/scene draw: sets clear colour, pipeline, uniforms, builds fixed-point quad lists, texture binds
0x18002c281 35425first game function after the runtime — likely the main state machine
0x1800ecd865549highest fan-out: init / asset loading orchestration
0x1800d1c46127few callees, long body — physics or collision arithmetic
0x180154bc5175same shape

The host surface Mini Golf actually needs

Static scan of every B/BL into the thunk arrays plus literal-pool references, reproducing §18.1's hand count exactly. 65 distinct ordinals, of which 44 were observed live in the probe sessions.

FrameworkUsed / publishedOrdinals (call sites)
OpenGLES20 / 179#4 glBindTexture(9) #12 glClear(3) #13 glClearColor(4) #19 glCompressedTexImage2D #21 glCopyTexImage2D #36(8) #37 glDrawArrays(4) #40 glEnableVertexAttribArray(8) #53 glGetError(24) #84 glPixelStorei #99 glTexImage2D #101 glTexParameterf #125 glUniformMatrix4fv #137 glVertexAttribPointer(8) #157 swap #158 private enable #159 pipeline select(5) #165 loadIdentity(2) #167 ortho #175 multMatrix(3)
Audio25 / 61sound-engine handle table #0 #1 #2 #7–#15 #17 #18 #23; music player #40 #42 #43 #45 #48(repeat) #51–#53 #55 #56
miscTBD10 / 15#0 alloc #5 #6 #7 level trio #9 µs clock(3) #10=1000 #11=0 #12 wall clock #13 battery #14 resolve resource name
AsyncFileIO8 / 17#0 #1 #2 #3 #4 #12 #14 #16 — open/read/close with completion callbacks; #12/#14 are the un-modelled save path
InputEvents1 / 2#0 poll (wheel position + five button bits)
Settings1 / 3#0 "Language" (word index) — Mini Golf does not ask for TimeFormat

Across all 18 titles the surface is 161 ordinals (covscan), and 158 are already implemented in eapp-loader. So the library for all the games is about 2.5× the Mini Golf library, not 18×, and the Rust stubs are a working reference implementation to port from.

Things in that surface that are harder than they look

Three routes, and the one to take

Route A · static recompilation

ARM → C translator, N64Recomp-style

Walk the 415 Ghidra functions, emit one C++ function per ARM function with a 16-register CPU struct and a flat memory array; thunk calls become direct calls into the host library. You already have the decoder (arm7tdmi::disasm) and know every quirk the titles hit. Produces a mechanical but correct-by-construction port of all 18 titles at once, in weeks. It is also the single best verification harness for Route B.

Cost: ~1–2 weeks to first frame with this codebase. Output: not "source", but a portable binary.

Route B · manual decompilation

Ghidra-assisted rewrite into real C

304 functions. 237 of them are under 50 instructions and mostly read as finished C++ out of Ghidra once the globals and classes are typed. The 52 over 100 instructions are the real work — maybe two to five days each for the five giants. Realistic solo total, focused: 6–10 weeks to a playable port, plus whatever time the library takes. With aggressive LLM assistance on the small functions, the tail shrinks a lot; the large ones still want a human reading the fixed-point math.

Cost: months. Output: maintainable source you can actually port.

Route C · recommended

Recomp first, then replace function by function

Ship Route A so the game runs natively on macOS from day one, then swap recompiled functions for hand-written C++ one at a time. Every swap is verified by the oracle below. The host library is shared by both halves, so it is never throwaway work, and the port is always playable — you never have the "nothing works until everything works" cliff that kills decomp projects.

Verification without a matching compiler

Equivalence here is behavioural, and you can measure it precisely because every side effect the game has goes through the 65 ordinals. Define a session as (script of input events) → (ordered log of framework calls with arguments, plus framebuffer hashes at chosen frames). The emulator already produces that log; the port produces the same log from the same script. Diff them. When a hand-decompiled function replaces a recompiled one and the log is unchanged across the test scripts, it is correct for those paths. This is strictly stronger than "it looks right" and it is cheap — a name-entry session is 30 M instructions, under a second natively.

It also answers the "how do I know I'm done" question: the call-graph dump tells you which functions a script reached, so test scripts can be grown until every one of the 304 functions has been executed under diff.

Library architecture for the cross-platform goal

Abstract at the level the games already abstract at — the eight framework ABIs — not at GL. The games never see a GL context; they see 179 numbered entries, and the interesting ones are not standard GL anyway. Below the ABI, a very small platform layer is all any new target has to write.

game code (decompiled C, one .c per subsystem) ← Route B output recompiled functions (generated C++) ← Route A output, shrinks over time ──────────────────────────────────────────────────────────────── ipod_eapp.h — C ABI for the 8 frameworks, 161 entries, numbered exactly as the thunks are (so recomp + decomp share it) ──────────────────────────────────────────────────────────────── eapp runtime — framework semantics, platform-independent C: gl subset → quad list builder, pipeline table (§17), 16.16 → float audio → 4-voice SFX pool, music transport + repeat mode asyncfileio → request objects, completion queue drained per frame input → detent counter, 5 button bits, both delivery mechanisms misc/settings → clock, battery, language, alloc ──────────────────────────────────────────────────────────────── platform backend — what a port actually writes (~6 functions each): present(quads[], textures) window/surface, frame pump mix(pcm) decode(m4a) file read/write in game dir poll() → detents, buttons clock, battery, locale SDL3 + GL/Metal via SDL_GPU · SDL2 · Vulkan/MoltenVK · citro2d (3DS)

Backend choice: start with SDL3 — it gives you the window, audio device, input and file paths on macOS, iOS, Android, Windows and Linux from one codebase, and its GPU API or plain OpenGL handles a textured-quad renderer with no effort. Keep an SDL2 backend only if you need a platform SDL3 has dropped. Vulkan/MoltenVK buys nothing for 320×240 quad drawing; treat it as an optional backend, not the foundation. The 3DS has neither SDL3 nor GL, which is exactly why the backend boundary is "draw these quads", not "here is a GL context". Write the reference backend first as a software rasteriser — it is a port of the one in lib.rs and doubles as the bit-exact comparison target.

Tooling that exists as of this session

What to do first

  1. Unblock a played hole in the emulator: model AsyncFileIO #12/#14 (the 0xc0debabe request) so the ✓ confirm proceeds, and add a "move N cells" script primitive. Then record a gameplay script and get the coverage number for the remaining 113 functions. Everything downstream wants that script.
  2. Freeze the ABI header — ipod_eapp.h generated from the descriptor tables plus the names in opengles-names.json/framework-functions.json. Both routes and the library compile against it.
  3. Port the Rust stubs to the C++ runtime for Mini Golf's 65 ordinals, with a software-rasteriser backend and an SDL3 window. Test it by driving it from the emulator's call log (replay the log into the library, compare framebuffers) before any game code exists.
  4. Write the ARM→C emitter over arm7tdmi::disasm and Ghidra's function list; link against the runtime; diff call logs against the emulator.
  5. Start replacing functions — the runtime-library bucket first (free), then the 237 small functions, then the state machine, then the renderer and physics. Type the global structs early; the base-pointer addressing means one good struct definition cleans up dozens of functions at once.

All numbers measured on Games_RO/88888/Executables/Minigolf_1_1_2563296.bin (227 868 bytes) with Ghidra 12.1.3 default ARM analysis; the function count will move a little once thunks are marked as externals and the 41 caller-less entries are confirmed as callbacks rather than dead code.